ProgrammingIncluded

Rust vs Modern C++ Part 2

Let's Borrow the Polymorph Potions

Charles Chen | Thu 2025-04-03 01:50 PM GMT-7

Table of Contents

Intro

Can you believe it has been 2 years since I last wrote my post? These blogs take a long time to write.

In Part 1, we kicked off our comparison between Rust and Modern C++ (focusing on C++11/14 standards and philosophies like those in Effective Modern C++). We looked at the basics: initializing data structures, iteration, lambdas, and smart pointers. The verdict? Rust often provided cleaner syntax and less ambiguity (winning 3/4 categories in my opinion), though C++ smart pointers felt simpler initially compared to Rust's Box, Arc, Rc, and the underlying rules.

My core thesis remains:

Modern C++ is arguably filled with gotcha's, edgecases, and solutions to problems stemming from C++ limitations.

As a result, Rust benefits from faster design cycles, more efficient code, and is arguablely an easier language to maintain even with its current rough edges in certain edge cases.

Today, we dive into the heart of what makes Rust... well, Rust. We'll explore two foundational concepts: Traits (Rust's answer to interfaces and generic constraints) and the infamous Ownership and Borrowing system.

Recap of the Roadmap

  • The Basics: Datastructures, Lambdas, and Heap (Covered in Part 1)
  • Traits and Ownership (This Post!)
  • Macros: Metaprogramming Made Simple
  • Cargo: The Rust Packages Ecosystem
  • C Interop and Unsafe Rust: The Dark Arts

Defining Shared Behavior via Polymorphism

As scientists begin to solve problems, it is inevitable we start to see patterns. More specifically, we realize how we interact with different types can be similar. Consider the concept of shape. Every concave shape has a defined number of sides. A good programming language should allow for the concept of polymorhism in their data types such that different types can be passed to the same function.

How do we write generic code that works with different types, ensuring those types can actually do what our code expects? Both languages have mechanisms for this. We will be focusing on polymorphism which are a property of classes that allow for simlar or shared interfaces. Generics will also be briefly covered which are used in algorithms or functions but will not be the highlight of this article.

The C++ Way: Inheritance, Templates, and Concepts

Modern C++ primarily builds off class inheritance and templates as means for type-generics. C++ rather cleanly splits them into runtime based and compile-time based resolutions.

Runtime: Class Inheritance & Virtual Functions

The classic OOP approach. Define a base class with virtual functions, and derived classes override them. This gives runtime polymorphism but comes with costs: vtable lookup overhead, potential for object slicing, rigid hierarchies, and the complexities of multiple inheritance ("the diamond problem").

Runtime polymorphism is when types are resolved at runtime. Vtables are the primary ways of resolving.

Virtual Functions and Inheritance Example
#include <iostream>

struct Drawable {
    virtual ~Drawable() = default; // Important! Virtual destructor
    virtual void draw() const = 0; // Pure virtual function (interface)
};

struct Circle : Drawable {
    void draw() const override { std::cout << "Drawing Circle" << std::endl; }
};

struct Square : Drawable {
    void draw() const override { std::cout << "Drawing Square" << std::endl; }
};

void draw_all(const std::vector<std::unique_ptr<Drawable>>& shapes) {
    for (const auto& shape : shapes) {
        shape->draw(); // Dynamic dispatch via vtable
    }
}

Compile Time: Templates

C++'s powerhouse for static polymorphism. Instead of waiting for a lookup table during runtime, templates resolve during compile-time.

Code is generated at compile time for each type used. Blazingly fast during runtime, but historically plagued by terrible error messages if a type didn't meet implicit requirements (the infamous "template metaprogramming wall of text"). Pre-C++20, techniques like SFINAE (Substitution Failure Is Not An Error) were used to constrain templates, often leading to complex code. Furthermore, compile time can easily increase non-linearlly. Template resolution can easily grow linearly by type and number of typed parameters if developers rely on fall-back implementations as the compiler would need to each combination.

The rule of Chiel is an example of how developers attempt to optimize and reduce template metaprograms. These were rules used when designing the boost library as this SO post mentions:

Optimizations based on the above rules were used when Boost.TMP was designed and developed. As much as possible, avoid top constructs for quick template compilation.

You can find somework on verifying this theory here. From highest cost to lowest:

SFINAE

Otherwise known as: Subsistution Failure is not an Error. A rule that allows C++ to exhaustively try every template definition for a given type if the most likely one fails as a fall-back. Much of the conditional templates in C++14 captilize on this behavior to for use in metaprogramming.

The simple example:

template<class T, class U>
struct is_same : std::false_type {};
 
template<class T>
struct is_same<T, T> : std::true_type {};

// This example shows how easy it is for C++ to bruteforce templates until one works.
// The rule of thumb is that the most specific template will be tried first.
is_same<int, int>() // Resolves to the bottom most template and returns true always.
is_same<int, bool>() // fails the bottom most template, however SFINAE and goes to the first, always returning false.

Here is a more complex one:

template <typename T>
class Container {
private:
    std::vector<T> items;

public:
    void add(const T& item) {
        items.push_back(item);
    }

    void print() const {
        std::cout << "Container contents: ";
        for (const auto& item : items) {
            std::cout << item << " ";
        }
        std::cout << std::endl;
    }

    // SFINAE in action:
    // This 'sum()' member function template is enabled ONLY IF std::is_arithmetic<T>::value is true.
    // We use std::enable_if in the return type.
    // If the condition (std::is_arithmetic<T>::value) is false, std::enable_if<...>::type is ill-formed.
    // SFINAE kicks in: this function signature is invalid, so it's removed from the class definition
    // for non-arithmetic types, instead of causing a hard compiler error.
    template <typename U = T> // Dummy template param often needed for SFINAE on member fns dependent on class params
    typename std::enable_if<std::is_arithmetic<U>::value, U>::type
    sum() const {
        std::cout << "(Calculating sum for arithmetic type)" << std::endl;
        // Need a default value appropriate for arithmetic types
        T total = T(); // Default constructor (0 for built-in arithmetic types)
        return std::accumulate(items.begin(), items.end(), total);
    }
};
Instantiating a function template
void f1(auto);
void f2(C1 auto);

// Or older syntax
template <typename T>
void my_func(T x);
Instantiating a type
template <typename T>
class Container {
private:
  std::vector<T> items;
public:
    void add(T item) {
        items.push_back(item);
    }
};

int main() {
    Container<int> int_container;
    Container<std::string> string_container;
    Container<double> double_container;
    return 0;
}
Calling an alias
template<typename T>
constexpr void my_function();

template<typename T>
constexpr auto alias_name = my_function<T>;

alias_name<int>(); // Still has to resolve template but now an extra indirection.
Adding a parameter to a type
// Template with one parameter
template <typename T>
struct SimpleBox {
    T value;
};

// Template with three parameters
template <typename T1, typename T2, int Size>
struct ComplexBox {
    T1 value1;
    T2 value2;
    int data[Size];
};
Adding a parameter to an alias call
// Alias with one parameter
template <typename T>
using Vec = std::vector<T>;

// Alias with two parameters (value type and allocator)
template <typename T, typename Alloc = std::allocator<T>>
using AllocVec = std::vector<T, Alloc>;
Looking up a memoized type

It wasn't clear to me from the talk what "memoized" meant here however it could either mean basic type lookup where resolution is already in memory or if templates are memoized after being resolved:

#include <vector>
#include <iostream>

// Function using std::vector<int>
void process_int_vector(const std::vector<int>& vec) {
    std::cout << "Processing vector with size: " << vec.size() << std::endl;
    // ... process ...
}

// Another function also using std::vector<int>
std::vector<int> create_int_vector(int n) {
    std::vector<int> new_vec; // Requires definition of std::vector<int>
    for (int i = 0; i < n; ++i) {
        new_vec.push_back(i * 2);
    }
    return new_vec;
}

int main() {
    // The compiler needs the definition of std::vector<int> here.
    // It likely instantiates it fully if this is the first use.
    std::vector<int> my_vector = create_int_vector(5);

    // The compiler also needs std::vector<int> here.
    // It can likely reuse ("look up") the definition it
    // already generated/instantiated previously. This lookup is
    // considered relatively cheap compared to a full new instantiation.
    process_int_vector(my_vector);

    std::vector<int> another_vector; // Reuse again

    return 0;
}

Honorable Mention: Concepts in C++20

A huge improvement introduced after our C++14 baseline, but essential for a truly modern comparison. Concepts allow you to explicitly define requirements and chain them (called, constraints) for template parameters.

This construct is entirely optional and can be considered as being built on-top of templates. This can be an entire article on its own.

Concepts effectively allow you to check certain properties about a type, chain these properties together (called constraints) and catch template errors earlier onwards. It will probably be the recommended way of template programming C++20 and onwards as error messages are cleaner.

#include <concepts> // C++20
#include <iostream>

// Define a concept 'Drawable' requiring a 'draw()' member function
template<typename T>
concept Drawable = requires(const T& t) {
    { t.draw() } -> std::same_as<void>; // Check if t.draw() exists and returns void
};

// Constrain the template using the concept
template<Drawable T>
void draw_static(const T& shape) {
    shape.draw(); // Static dispatch
}

struct Triangle { // Doesn't inherit, just needs the method
    void draw() const { std::cout << "Drawing Triangle (Concept)" << std::endl; }
};

// Usage:
// Triangle t;
// draw_static(t); // Compiles because Triangle satisfies Drawable concept

Error example from cppreference shows how clean errors can be:

std::list<int> l = {3, -1, 10};
std::sort(l.begin(), l.end()); 
// Typical compiler diagnostic without concepts:
// invalid operands to binary expression ('std::_List_iterator<int>' and
// 'std::_List_iterator<int>')
//                           std::__lg(__last - __first) * 2);
//                                     ~~~~~~ ^ ~~~~~~~
// ... 50 lines of output ...
//
// Typical compiler diagnostic with concepts:
// error: cannot call std::sort with std::_List_iterator<int>
// note:  concept RandomAccessIterator<std::_List_iterator<int>> was not satisfied

The Rust Way: Traits

Rust really only has one supported way of polymorphism for class types and that is through Traits. Traits are primarily resolved statically but also incorporate runtime polymorphism. You can think of traits as incorporating some parts of C++ "concepts" as a means of "constraining" with interface-like definitions.

Though we are mainly focusing on classes and struct polymorphism, it can be noted that Rust does support functional generics which are essentially similar in syntax to C++ functional templates however do not support SFINAE and other complex type-systems that C++ offers.

Definition & Implementation

Define a contract with trait, implement it with impl Trait for Type. You can think of it as defining "interface" classes but do not have to be purely abstract.

// Define the trait (interface)
trait Summary {
    fn summarize(&self) -> String; // Method signature

    // Traits can also have default implementations
    fn default_summary(&self) -> String {
        String::from("(Read more...)")
    }
}

struct NewsArticle {
    headline: String,
    content: String,
}

// Implement the trait for NewsArticle
impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, {}", self.headline, self.default_summary())
    }
}

struct Tweet {
    username: String,
    content: String,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Compile Time: Generic Constraints for Traits

Use traits to ensure generic functions get types are constrainted by their behavior. It is similar to constraints in C++ however is considered "nominal type" which is explicit. The type must satisfy a struct that derives from this trait, no exceptions. Constraints in C++, however, are entirely optional.

// Constrain T to types implementing the Summary trait
fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

// Alternative 'where' clause syntax
fn notify_verbose<T>(item: &T)
where
    T: Summary, // Requires Summary trait
{
    println!("Detailed report: {}", item.summarize());
}

More than one constraint can be added per-generic, making this system very powerful:

// Alternative 'where' clause syntax
fn notify_verbose<T>(item: &T)
where
    T: Summary + AnotherConstraint + YetAnother,
{
    println!("Detailed report: {}", item.summarize());
}

Runtime: Dynamic Dispatch for Traits

Traits enable both:

  • Static Dispatch (Monomorphization): Like C++ templates, the compiler generates specialized code for each concrete type used (e.g., notify::<NewsArticle>(...)). This is the default and has zero runtime overhead.
  • Dynamic Dispatch (Trait Objects): Use dyn Trait to create objects that hide the concrete type but guarantee they implement the trait, similar to C++ virtual functions (but explicit). Requires pointers like &dyn Summary or Box<dyn Summary>.
fn dynamic_notify(item: &dyn Summary) { // Takes a reference to any type implementing Summary
    println!("Dynamic dispatch: {}", item.summarize());
}

// Usage:
// let article = NewsArticle { ... };
// let tweet = Tweet { ... };
// dynamic_notify(&article);
// dynamic_notify(&tweet);
// let summaries: Vec<Box<dyn Summary>> = vec![Box::new(article), Box::new(tweet)];

Traits vs. C++ Abstraction: The Verdict

Let's compare the two languages:

  • Unified System: Rust's Traits provide a single, cohesive system for defining interfaces, constraining generics, and enabling both static and dynamic polymorphism. C++ uses separate mechanisms (inheritance/virtual for dynamic, templates/concepts for static).
  • Clarity & Safety: Traits generally lead to clear compile-time errors if constraints aren't met. C++20 Concepts dramatically improved C++ template errors, bringing them closer to Rust's level of clarity, but pre-C++20 template errors were notoriously difficult.
  • Expressiveness: C++ wins a bit in this regard in terms of expressiveness, though they are different. C++ Concepts allow very fine-grained syntactic checks via requires expressions and constraints on almost any C++ type. While traits are limited to structs and class methods.
  • Integration: Traits are absolutely fundamental to Rust's design and standard library, basically mandatory. Concepts are a powerful layer added onto C++'s existing template system, completely optional.
  • Maitenance: Though the jury is still out on concepts, it seems Rust is less likely to lead to mistakes and maintenance issues given its explicit nominal type. A struct has to associate with a trait rather than a soft requirement.

Verdict: Rust.

While C++20 Concepts are a fantastic and much-needed addition that significantly narrows the gap (especially regarding clear generic constraints), Rust's trait system feels more foundational, unified, and inherently integrated into the language's approach to abstraction and polymorphism. It avoids the historical baggage and separate mechanisms found in C++.

Ownership and the Borrow Checker

This is arguably the biggest differentiator. How do we manage resources (especially memory) safely and prevent common bugs like dangling pointers or data races?

The C++ Way: RAII and Smart Pointers

As discussed in Part 1, Modern C++ relies heavily on RAII (Resource Acquisition Is Initialization). Object lifetime manages resource lifetime. Smart pointers (std::unique_ptr, std::shared_ptr) automate this for heap allocations.

  • std::unique_ptr: Exclusive ownership. The resource is deleted when the unique_ptr goes out of scope. Cheap, efficient.
  • std::shared_ptr: Shared ownership via reference counting. The resource is deleted only when the last shared_ptr referencing it is destroyed. Incurs reference counting overhead.

This system works well most of the time and is much safer than manual new/delete. However, pitfalls remain:

  • Dangling pointers/references: Still possible if using raw pointers, returning references to local variables, or mishandling std::weak_ptr in cyclic shared_ptr scenarios.
  • Data Races: Smart pointers don't inherently prevent data races in multithreaded code. You still need manual synchronization (mutexes, atomics) to protect shared data, which is notoriously hard to get right.

C++ basically says, the developer has to be the one to ensure safetly. This assumes developers can identify complex code manually and determine if a pointer was left unaddressed or some data race had occurred.

The Rust Way: Ownership, Borrowing, Lifetimes

Rust enforces memory safety at compile time through a strict set of rules enforced by the "borrow checker" introduced by the compiler on all variables:

Ownership

Each value in Rust has a single owner:

  • When the owner goes out of scope, the value is dropped (memory freed, resources released).
  • Ownership moves by default. Assigning a value or passing it to a function transfers ownership (unless it's a Copy type like simple integers).
{
    let s1 = String::from("hello"); // s1 owns the String data
    let s2 = s1; // Ownership of the String data MOVES from s1 to s2
    // println!("{}", s1); // ERROR! s1 is no longer valid, ownership moved
    println!("{}", s2);
} // s2 goes out of scope, the String data is dropped

Borrowing (References)

Instead of moving ownership, you can lend access via references (borrows).

  • You can have multiple immutable references (&T) simultaneously.
  • You can have only one mutable reference (&mut T) at a time.
  • Crucially: You cannot have mutable references while immutable references exist. (This prevents data races!)
let mut s = String::from("hello");

let r1 = &s; // OK - immutable borrow
let r2 = &s; // OK - another immutable borrow
println!("{}, {}", r1, r2); // OK

// let r3 = &mut s; // ERROR! Cannot borrow `s` as mutable because it is also borrowed as immutable

// Scope of r1 and r2 ends here implicitly because they are no longer used.

let r3 = &mut s; // OK - no other borrows active
r3.push_str(", world!");
println!("{}", r3);

Another way to phrase is it that all variables are constant. If it isn't constant, only one reference can modify it at a time.

Lifetimes

The compiler tracks the scope for which references are valid, ensuring they never outlive the data they point to (preventing dangling references). Often inferred (elided), but sometimes require explicit annotation ('a).

// fn dangling_ref() -> &String { // ERROR: returns reference to data owned by the function
//     let s = String::from("hello");
//     &s // reference to `s`, but `s` will be dropped here!
// }

The combination of these rules allows Rust to guarantee memory safety and data-race freedom (in safe Rust code) at compile time without needing a garbage collector.

How about the Heap or Multi-threading?

The rules for the borrow-checker applies for all variables within Rust, however, what about objects that exist on the heap that exist beyond the lifetime of the scope? These traits ensure certain properties about the memory being pointed by these variables that ensure the borrow-checker's check on references will propogated to the heap.

Rust also introduces more traits and concepts for asynchronous coding which we will not be covering in this article. But streams and channels help prevent data-races at a minor overhead.

Ownership/Borrowing vs. RAII/Smart Pointers: The Verdict

Let's compare the two languages.

  • Safety Guarantees: Rust wins, hands down. The borrow checker eliminates entire classes of bugs (use-after-free, double free, data races) at compile time. C++ relies on programmer discipline and runtime tools (sanitizers) to catch these.
  • Concurrency: Rust's "fearless concurrency" stems directly from the borrow checker preventing data races. Writing safe concurrent C++ requires meticulous manual locking. Rust wins decisively here.
  • Learning Curve: C++ smart pointers are generally easier to pick up initially. Rust's borrow checker rules are strict and often frustrating for newcomers, requiring a different way of thinking about program structure.
  • Flexibility: C++ offers more direct control and ways to structure code, sometimes needed for complex object graphs or performance tuning (though Rust's unsafe provides an escape hatch). Certain patterns (like mutable doubly linked lists) are notoriously harder in safe Rust due to the borrow rules.
  • Performance: Both offer excellent theoretical performance. Rust's compile-time checks impose no runtime overhead compared to GC languages. C++ shared_ptr has runtime ref-counting overhead; unique_ptr is closer to Rust's Box.

Verdict: This is the core trade-off. Rust provides unparalleled safety guarantees enforced by the compiler, especially vital for concurrency, but demands a steeper learning curve. Modern C++ offers easier initial adoption for resource management via smart pointers but leaves the door open for subtle, dangerous bugs and requires significant developer discipline, especially in concurrent contexts. For safety and maintainability, especially in complex or concurrent systems, the borrow checker's upfront cost pays dividends later.

Conclusion

We've tackled the heavyweights: Traits and Ownership.

  • Traits vs. C++ Abstraction: Rust's trait system provides a unified, powerful, and safe way to handle abstraction and polymorphism, feeling more cohesive than C++'s mix of inheritance, templates, and (the excellent but newer) concepts. Rust wins.
  • Ownership/Borrowing vs. C++ RAII/Smart Pointers: Rust's compile-time guarantees against memory errors and data races are revolutionary, making it incredibly robust, especially for concurrency. The cost is a steeper learning curve compared to C++ smart pointers, which are simpler but less safe. Rust wins on safety, C++ wins on initial simplicity.

Revisiting the thesis: Do these core Rust features contribute to faster design, efficiency, and maintainability? I believe so. While the borrow checker requires an initial investment, the classes of bugs it prevents significantly reduce debugging time and increase confidence, particularly in large or concurrent systems. Traits provide a clean abstraction mechanism. These factors often outweigh the initial learning curve and the occasional ergonomic challenges compared to the potential pitfalls and complexities lurking in even modern C++.

Rust isn't perfect (as we saw with returning closures in Part 1 and the complexity of some borrowing patterns), but its foundational design choices around traits and ownership offer compelling advantages.

In Part 3, we'll look at Rust's metaprogramming powerhouse (Macros) and its universally acclaimed build system and package manager (Cargo). Stay tuned!